--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 503ea58b852a8fa4be4dd7bdcc1cff4488392bc5
Parents : 6484037
Author : Mark Qvist <bc7291552be7a58f361522990465165c>
Date : 2026-05-21T00:17:39+02:00
Added RRC nick colors option
Changes
4 files changed, 366 insertions(+), 11 deletions(-)
Diff
diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py
index 7b4483a..e18663d 100644
--- a/nomadnet/NomadNetworkApp.py
+++ b/nomadnet/NomadNetworkApp.py
@@ -156,6 +156,7 @@ class NomadNetworkApp:
self.rrc_history_per_room_cap = 500
self.rrc_filter_loaded_history = True
self.rrc_ephemeral_notices = 600
+ self.rrc_nick_colors = True
self.rrc_ui_justify_msgs = True
self.rrc_ui_space_msgs = False
self.rrc_ui_render_markdown = True
@@ -944,6 +945,11 @@ class NomadNetworkApp:
except Exception: value = False
self.rrc_ui_space_msgs = value
+ if option == "nick_colors":
+ try: value = self.config["rrc"].as_bool(option)
+ except Exception: value = True
+ self.rrc_nick_colors = value
+
if option == "render_markdown":
try: value = self.config["rrc"].as_bool(option)
except Exception: value = True
@@ -1296,6 +1302,7 @@ ephemeral_notices = 10
# Other display and formatting options:
render_markdown = yes
render_micron = yes
+nick_colors = yes
justify_msgs = yes
space_msgs = no
diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py
index e7e72df..07e33fb 100644
--- a/nomadnet/ui/textui/Channels.py
+++ b/nomadnet/ui/textui/Channels.py
@@ -13,7 +13,8 @@ from nomadnet.ui.textui.MicronParser import LinkableText, LinkSpec
from RNS.Utilities.rngit.util import MarkdownToMicron
from RNS.Utilities.rngit.highlight import SyntaxHighlighter
from .MicronParser import markup_to_attrmaps
-from nomadnet.util import strip_modifiers, strip_micron, strip_escaped_micron, unescape_micron, strip_non_formatting_tags
+from nomadnet.util import sanitize_name, strip_modifiers, strip_micron
+from nomadnet.util import strip_escaped_micron, unescape_micron, strip_non_formatting_tags
theme_dark = { "text": "ddd",
@@ -24,7 +25,10 @@ theme_dark = { "text": "ddd",
"error": "f55",
"system": "888",
"mention": "fb4",
- "link": "79d", }
+ "link": "79d",
+ # colorgen.py --hue-step 18 --sat-start 25 --sat-steps 2 --sat-step 100 --light-step 30 --normalize --normalize-target 2.5 --perceptual-multiplier 1.4 --discard 1,5,7,11,13,14,17,27,31,33,37,39,3,16,18,36
+ "nick_colors": ["f68787", "00c394", "d59e00", "62be00", "a1ac76", "95b600", "76a9ee", "81b385", "7eb1a1", "e89264", "7cb0b0", "00c0c0", "8cacbb", "32b4db", "98a8c3", "bbab00", "95a0fd", "a9a2ca", "ad98fe", "c58ffa", "df83f4", "c49abf", "f380c7", "f484a7"],
+ }
theme_light = { "text": "111",
"ts": "888",
@@ -34,7 +38,10 @@ theme_light = { "text": "111",
"error": "a22",
"system": "888",
"mention": "c50",
- "link": "79d", }
+ "link": "79d",
+ # colorgen.py --hue-step 18 --sat-start 25 --sat-steps 2 --sat-step 100 --light-step 30 --normalize --normalize-target 2.5 --perceptual-multiplier 0.2 --discard 1,5,7,11,13,14,17,27,31,33,37,39,3,16,18,36 > ~/.nomadnetwork/storage/pages/index.mu
+ "nick_colors": ["ca0000", "008000", "9d1c00", "007800", "2c5200", "006800", "004ac0", "006100", "005d2c", "b70000", "005b5b", "007b7a", "005071", "0064a5", "004580", "714f00", "0026d3", "48318c", "5200d5", "8400cf", "aa00c8", "820079", "c60086", "c80043"],
+ }
class _ChatLinkableText(LinkableText):
@@ -451,6 +458,7 @@ class RoomWidget(urwid.WidgetWrap):
rows = [urwid.Text(" "+str(len(names))+" user"+("s" if len(names) != 1 else ""))]
for name, is_self in names:
+ name = sanitize_name(name); name = name[:15]+"…" if len(name) > 16 else name
if is_self:
rows.append(urwid.AttrMap(urwid.Text(" "+g["arrow_r"]+" "+name), "list_trusted"))
else:
@@ -901,7 +909,14 @@ class _ChatLinkDelegate:
except Exception as e:
RNS.log("Could not open page link: "+str(e), RNS.LOG_ERROR)
+def get_nick_color(sender_hash, theme, shift=15):
+ if type(sender_hash) == str:
+ try: sender_hash = sender_hash.encode("utf-8")
+ except: pass
+ if not type(sender_hash) == bytes: return theme["nick_peer"]
+ return theme["nick_colors"][(int.from_bytes(sender_hash)+shift)%len(theme["nick_colors"])]
+room_nick_src_cache = {}
mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None)
def _message_widget(app, hub, m, link_delegate=None):
t = theme_dark if app.config["textui"]["theme"] == nomadnet.ui.TextUI.THEME_DARK else theme_light
@@ -944,12 +959,12 @@ def _message_widget(app, hub, m, link_delegate=None):
except Exception:
pass
- if m.nick:
- sender = m.nick
- elif isinstance(m.src, (bytes, bytearray)):
- sender = _short_hash(m.src)
- else:
- sender = "?"
+ if m.nick: sender = sanitize_name(m.nick)
+ elif isinstance(m.src, (bytes, bytearray)): sender = _short_hash(m.src)
+ else: sender = "?"
+
+ if isinstance(m.src, (bytes, bytearray)):
+ room_nick_src_cache[m.nick] = m.src
nick_attr = "irc_nick_self" if own else "irc_nick_peer"
body = m.text or ""
@@ -960,7 +975,12 @@ def _message_widget(app, hub, m, link_delegate=None):
for span in spans:
ms = span[0]
mb = span[1]
- if ms.startswith("irc_mention"): message_body += f"`!`F{t['mention']}{mb}`f`!"
+ if ms.startswith("irc_mention"):
+ if not app.rrc_nick_colors: message_body += f"`!`F{t['mention']}{mb}`f`!"
+ else:
+ try: message_body += f"`!`FT{get_nick_color(room_nick_src_cache[m.nick], t)}{mb}`f`!"
+ except: message_body += f"`!`F{t['mention']}{mb}`f`!"
+
elif ms.startswith("link_"):
kind = ms[len("link_"):]
label = mb.split("`")[0]
@@ -978,7 +998,8 @@ def _message_widget(app, hub, m, link_delegate=None):
mbo = mdc.format_block(strip_escaped_micron(mb)) if app.rrc_ui_render_markdown else strip_escaped_micron(mb)
message_body += strip_non_formatting_tags(mbo)
- nick_attr = f"`F{t['nick_self']}" if own else f"`F{t['nick_peer']}"
+ if app.rrc_nick_colors: nick_attr = f"`FT{get_nick_color(m.src, t)}"
+ else: nick_attr = f"`F{t['nick_self']}" if own else f"`F{t['nick_peer']}"
irc_ts = f"`F{t['ts']}"
prefix_micron = f"{irc_ts}{_ts_prefix_raw(m.ts)}"
diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py
index 7a8e05a..5953e2b 100644
--- a/nomadnet/ui/textui/Guide.py
+++ b/nomadnet/ui/textui/Guide.py
@@ -811,6 +811,12 @@ Whether or not to render markdown formatting in messages.
Whether or not to render micron formatting in messages. When using micron in messages, use the ¦ character in place of backticks.
<
+>>>
+`!nick_colors = yes`!
+>>>>
+Whether or not to render RRC nicks in distinct colors based on identity hash.
+<
+
>>>
`!justify_msgs = yes`!
`!space_msgs = no`!
diff --git a/tools/colorgen.py b/tools/colorgen.py
new file mode 100644
index 0000000..0ff636a
--- /dev/null
+++ b/tools/colorgen.py
@@ -0,0 +1,321 @@
+#!/usr/bin/env python3
+"""
+Color Table Generator with Perceptual Normalization
+
+Generates HSL color tables with optional normalization to uniform perceptual
+intensity (CIELAB L*) followed by global lightness scaling.
+"""
+
+import argparse
+import colorsys
+import math
+import sys
+
+
+def hsl_to_rgb(h, s, l):
+ """Convert HSL (0-360, 0-100, 0-100) to RGB (0.0-1.0)."""
+ h_norm = (h % 360) / 360.0
+ s_norm = max(0, min(100, s)) / 100.0
+ l_norm = max(0, min(100, l)) / 100.0
+ r, g, b = colorsys.hls_to_rgb(h_norm, l_norm, s_norm)
+ return (r, g, b)
+
+
+def rgb_to_hex(r, g, b):
+ """Convert RGB 0.0-1.0 to lowercase hex string."""
+ r = max(0, min(1, r))
+ g = max(0, min(1, g))
+ b = max(0, min(1, b))
+ return f"{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}"
+
+
+# CIELAB conversion functions
+def srgb_to_linear(c):
+ if c <= 0.04045:
+ return c / 12.92
+ return ((c + 0.055) / 1.055) ** 2.4
+
+
+def linear_to_srgb(c):
+ if c <= 0.0031308:
+ return c * 12.92
+ return 1.055 * (c ** (1/2.4)) - 0.055
+
+
+def rgb_to_lab(r, g, b):
+ """Convert sRGB (0-1) to CIELAB (L: 0-100, a,b: ~-128 to 127). D65 illuminant."""
+ # sRGB to Linear
+ r_lin = srgb_to_linear(r)
+ g_lin = srgb_to_linear(g)
+ b_lin = srgb_to_linear(b)
+
+ # Linear to XYZ (D65)
+ X = 0.4124564 * r_lin + 0.3575761 * g_lin + 0.1804375 * b_lin
+ Y = 0.2126729 * r_lin + 0.7151522 * g_lin + 0.0721750 * b_lin
+ Z = 0.0193339 * r_lin + 0.1191920 * g_lin + 0.9503041 * b_lin
+
+ # XYZ to LAB
+ Xn, Yn, Zn = 95.047, 100.0, 108.883
+ delta = 6/29
+
+ def f(t):
+ if t > delta**3:
+ return t ** (1/3)
+ return t / (3 * delta**2) + 4/29
+
+ L = 116 * f(Y / Yn) - 16
+ a = 500 * (f(X / Xn) - f(Y / Yn))
+ b_val = 200 * (f(Y / Yn) - f(Z / Zn))
+
+ return (L, a, b_val)
+
+
+def lab_to_rgb(L, a, b_val):
+ """Convert CIELAB to sRGB (0-1)."""
+ Xn, Yn, Zn = 95.047, 100.0, 108.883
+ delta = 6/29
+
+ def inv_f(y):
+ if y > delta:
+ return y ** 3
+ return 3 * delta**2 * (y - 4/29)
+
+ L_adj = (L + 16) / 116
+ X = Xn * inv_f(L_adj + a / 500)
+ Y = Yn * inv_f(L_adj)
+ Z = Zn * inv_f(L_adj - b_val / 200)
+
+ # XYZ to Linear RGB
+ r_lin = 3.2404542 * X - 1.5371385 * Y - 0.4985314 * Z
+ g_lin = -0.9692660 * X + 1.8760108 * Y + 0.0415560 * Z
+ b_lin = 0.0556434 * X - 0.2040259 * Y + 1.0572252 * Z
+
+ r = linear_to_srgb(r_lin)
+ g = linear_to_srgb(g_lin)
+ b = linear_to_srgb(b_lin)
+
+ return (r, g, b)
+
+
+def generate_colors(
+ hues=None,
+ hue_start=0.0,
+ hue_step=30.0,
+ sat_start=70.0,
+ sat_step=0.0,
+ sat_steps=1,
+ light_start=50.0,
+ light_step=0.0,
+ light_steps=1,
+ perceptual_multiplier=1.0,
+ use_hsl_space=False,
+ normalize=False,
+ normalize_target=None,
+ discards=[]
+):
+ """
+ Generate color table with optional perceptual normalization.
+
+ Pipeline:
+ 1. Generate HSL variations
+ 2. If normalize: Convert to LAB, set all L* to target (mean or specified)
+ 3. Apply global perceptual multiplier to L*
+ 4. Convert to RGB and output hex
+ """
+ # Determine hue list
+ if hues is not None:
+ hue_list = [float(h) % 360 for h in hues]
+ else:
+ hue_list = []
+ current = hue_start % 360
+ if hue_step <= 0:
+ raise ValueError("hue_step must be positive when using start/step mode")
+ while current < 360:
+ hue_list.append(current)
+ current += hue_step
+
+ # Determine if we need LAB processing
+ needs_lab = normalize or (perceptual_multiplier != 1.0) or not use_hsl_space
+
+ colors = []
+ lab_colors = [] # Store as (L, a, b) tuples if processing needed
+
+ for h in hue_list:
+ for i in range(sat_steps):
+ s = sat_start + (i * sat_step)
+ s = max(0, min(100, s))
+
+ for j in range(light_steps):
+ # l = light_start + (j * light_step)
+ l = light_start + (i * light_step)
+ l = max(0, min(100, l))
+
+ r, g, b = hsl_to_rgb(h, s, l)
+
+ if not needs_lab:
+ colors.append(rgb_to_hex(r, g, b))
+ else:
+ L, a, b_lab = rgb_to_lab(r, g, b)
+ lab_colors.append((L, a, b_lab))
+
+ filtered_colors = []
+ filtered_lab_colors = []
+ i = 0
+ for c in colors:
+ i += 1
+ if not str(i) in discards:
+ filtered_colors.append(c)
+
+ i = 0
+ for c in lab_colors:
+ i += 1
+ if not str(i) in discards:
+ filtered_lab_colors.append(c)
+
+ colors = filtered_colors
+ lab_colors = filtered_lab_colors
+
+ if not needs_lab:
+ return colors
+
+ # Step 2: Perceptual Normalization (equalize intensity)
+ if normalize:
+ if normalize_target is not None:
+ target_L = normalize_target
+ else:
+ # Calculate mean perceptual lightness
+ target_L = sum(lab[0] for lab in lab_colors) / len(lab_colors)
+ print(f"Target L*: {target_L}")
+
+ # Clamp target to valid LAB range
+ target_L = max(0, min(100, target_L))
+
+ # Normalize all colors to target L, preserving hue (a, b)
+ lab_colors = [(target_L, a, b) for (L, a, b) in lab_colors]
+
+ # Step 3: Apply final global perceptual multiplier
+ final_colors = []
+ for L, a, b in lab_colors:
+ L_final = L * perceptual_multiplier
+ L_final = max(0, min(100, L_final))
+ r, g, b = lab_to_rgb(L_final, a, b)
+ final_colors.append(rgb_to_hex(r, g, b))
+
+ return final_colors
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Generate perceptually normalized color tables",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+Pipeline: HSL Generation → [Normalize to uniform L*] → Global Multiplier → Hex
+
+Examples:
+ # 32 colors normalized to same perceptual intensity, then darkened 20%
+ python3 colorgen.py --hue-start 0 --hue-step 11.25 --sat-start 75 --normalize --perceptual-multiplier 0.8
+
+ # Force all colors to exactly L*=55, then scale by 1.1
+ python3 colorgen.py --hues "0,90,180,270" --normalize --normalize-target 55 --perceptual-multiplier 1.1
+
+ # Generate with lightness variation, normalize to mean intensity, output brightened
+ python3 colorgen.py --hue-start 15 --hue-step 30 --light-start 40 --light-step 10 --light-steps 3 --normalize --perceptual-multiplier 1.2
+ """
+ )
+
+ # Hue configuration
+ hue_group = parser.add_mutually_exclusive_group()
+ hue_group.add_argument("--hues", type=str, metavar="LIST",
+ help='Comma-separated hue values, e.g., "0,60,120"')
+ hue_group.add_argument("--hue-start", type=float, default=0.0,
+ help="Initial hue offset in degrees")
+ parser.add_argument("--hue-step", type=float, default=30.0,
+ help="Separation between hues")
+
+ # Saturation/Lightness configuration
+ parser.add_argument("--sat-start", type=float, default=75.0,
+ help="Initial saturation (0-100)")
+ parser.add_argument("--sat-step", type=float, default=0.0,
+ help="Saturation step")
+ parser.add_argument("--sat-steps", type=int, default=1,
+ help="Number of saturation variations")
+ parser.add_argument("--light-start", type=float, default=50.0,
+ help="Initial lightness (0-100)")
+ parser.add_argument("--light-step", type=float, default=0.0,
+ help="Lightness step")
+ parser.add_argument("--light-steps", type=int, default=1,
+ help="Number of lightness variations")
+
+ # Perceptual processing
+ parser.add_argument("--normalize", action="store_true",
+ help="Normalize all colors to same perceptual intensity (L*) before applying multiplier")
+ parser.add_argument("--normalize-target", type=float, default=None, metavar="L",
+ help="Target L* value (0-100) for normalization. Default: mean of generated colors")
+ parser.add_argument("--perceptual-multiplier", type=float, default=1.0,
+ help="Final global lightness multiplier (1.0=no change)")
+ parser.add_argument("--hsl-space", action="store_true",
+ help="Skip LAB conversion if not normalizing (faster, less accurate perceptually)")
+
+ # Output
+ parser.add_argument("--python-list", action="store_true",
+ help="Output as Python list")
+ parser.add_argument("--separator", type=str, default="\n",
+ help="Separator between colors")
+ parser.add_argument("--stats", action="store_true",
+ help="Print generation stats to stderr")
+ hue_group.add_argument("--discard", type=str, metavar="LIST",
+ help='Discard output indexes, e.g., "20,2,33"')
+
+ args = parser.parse_args()
+
+ if args.hsl_space and args.normalize:
+ print("Warning: --hsl-space ignored because --normalize requires LAB space", file=sys.stderr)
+ args.hsl_space = False
+
+ try:
+ if args.discard: discards = args.discard.split(",")
+ else: discards = []
+
+ colors = generate_colors(
+ hues=args.hues.split(",") if args.hues else None,
+ hue_start=args.hue_start,
+ hue_step=args.hue_step,
+ sat_start=args.sat_start,
+ sat_step=args.sat_step,
+ sat_steps=args.sat_steps,
+ light_start=args.light_start,
+ light_step=args.light_step,
+ light_steps=args.light_steps,
+ perceptual_multiplier=args.perceptual_multiplier,
+ use_hsl_space=args.hsl_space,
+ normalize=args.normalize,
+ normalize_target=args.normalize_target,
+ discards=discards,
+ )
+
+ i = 0
+ # Enable to test on light background
+ # print(f"#!bg=fff\n")
+ if args.python_list: print("colors = [", end="")
+ for c in colors:
+ i+=1
+ if args.python_list:
+ print(f"\"{c}\", ", end="")
+
+ else: print(f"`FT{c}colored {i}`f")
+
+ if args.python_list: print("]")
+
+ if args.stats:
+ total = len(colors)
+ mode = "normalized + " if args.normalize else ""
+ print(f"# Generated {total} colors ({mode}L* × {args.perceptual_multiplier})",
+ file=sys.stderr)
+
+ except Exception as e:
+ print(f"Error: {e}", file=sys.stderr)
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────